LRU Cache¶
- LRU stands for "Least Recently Used", a cache eviction policy that removes the least recently accessed item when the cache reaches capacity.
- Core idea: Ensures that the most recently used (MRU) elements remain in the cache, while the least recently used (LRU) elements get evicted first.
- Data structures used: Typically implemented using a combination of a hash map (for $O(1)$ key lookup) and a doubly linked list (for efficient reordering of usage).
- Operations supported:
- Get(key): Retrieves the value if present and moves the accessed item to the front (MRU position).
- Put(key, value): Adds or updates an item and moves it to the front. If the cache is full, removes the item at the back (LRU position) before inserting.
- Use cases: Frequently applied in memory management, CPU cache, web caching, and databases to optimize data retrieval and resource usage.
- Benefits: Maintains fast access time (O(1) for both get and put operations) and automatic eviction of old, unused data, making it efficient for limited-size memory situations.
Defining the Node class¶
In [1]:
class Node:
def __init__(self, key_: int, value_: int):
# Each node stores a key-value pair
self.value = [key_, value_]
self.prev = None # Pointer to the previous node
self.next = None # Pointer to the next node
def __str__(self):
return f"({self.value[0]}, {self.value[1]})"
def getKey(self):
return self.value[0]
def getValue(self):
return self.value[1]
def setValue(self, val_: int):
self.value[1] = val_
In [2]:
class LRUCache:
def __init__(self, capacity):
# Initialize the cache with a given capacity
self.capacity = capacity
self.number_of_elements = 0
self.cache_head = None # Most recently used
self.cache_tail = None # Least recently used
self.cache_map = {} # Dictionary to store key -> Node mapping
# Utility function to print the current state of the cache for debugging purposes
def print_cache(self):
temp_node = self.cache_head
while temp_node is not None:
print(temp_node, end=" ")
temp_node = temp_node.next
print()
Algorithm for get method¶
- Check if key exists:
- If the key does not exist in the map/dictionary return
-1(indicating a cache miss).
- If the key does not exist in the map/dictionary return
- Access the node:
- Retrieve the node corresponding to the key from the map.
- Move node to front (most recently used):
- If the node is not already at the front, remove it from its current position and insert it at the head of the doubly linked list.
- Update the previous and next pointers as needed.
- Update head and tail references if necessary.
- If the node is not already at the front, remove it from its current position and insert it at the head of the doubly linked list.
- Return value:
- Return the value associated with the node.
In [3]:
def get(self, key_: int):
# Return the value of the key if present, else -1
if key_ not in self.cache_map:
return -1
node = self.cache_map[key_]
# If node is already at the head, it's the most recently used
if node.prev is None:
return node.getValue()
# If node is at the tail, update tail and move node to head
if node.next is None:
self.cache_tail = self.cache_tail.prev
self.cache_tail.next = None
else:
# Remove node from its current position
node.prev.next = node.next
node.next.prev = node.prev
# Move node to the head
node.next = self.cache_head
self.cache_head.prev = node
node.prev = None
self.cache_head = node
return node.getValue()
Algorithm for put method¶
- Check for Key Existence:
- If the key is already present in the cache:
- Update its value.
- Move the corresponding node to the front (most recently used position).
- If the key is already present in the cache:
- If Key Not Present:
- If the cache is at full capacity:
- Remove the least recently used item (the tail of the linked list or the first item in an insertion-order map).
- Remove its entry from both the linked list and the key-node mapping (hashmap).
- Create a new node for the
keyandvalue.
- If the cache is at full capacity:
- Insert at Front:
- Insert the new or updated node at the front of the linked list (marks it most recently used).
- Update the key-node mapping in the hashmap to point to the front node.
- Time Complexity: Each of these sub-operations (checking, adding, removing, updating) takes O(1) time using a combination of hashmap and doubly linked list.
In [4]:
def put(self, key_: int, value_: int):
# modular code to update existing node and move it to the head
def updateNode():
node = self.cache_map[key_]
node.setValue(value_)
if node.prev is None:
return
if node.next is None:
self.cache_tail = self.cache_tail.prev
self.cache_tail.next = None
node.next = self.cache_head
self.cache_head.prev = node
node.prev = None
self.cache_head = node
return
node.prev.next = node.next
node.next.prev = node.prev
node.prev = None
node.next = self.cache_head
self.cache_head.prev = node
self.cache_head = node
# Insert or update the value_ of the key_
if self.capacity == 0:
# Special handling when capacity is 0
if self.number_of_elements == 1:
self.cache_map.pop(self.cache_tail.getKey())
self.cache_head = Node(key_, value_)
self.cache_tail = self.cache_head
self.cache_map[key_] = self.cache_head
return
if key_ in self.cache_map:
updateNode(); return
else:
self.cache_map.pop(self.cache_tail.getKey())
self.cache_tail = self.cache_tail.prev
self.cache_tail.next = None
node = Node(key_, value_)
self.cache_head.prev = node
node.next = self.cache_head
self.cache_head = node
self.cache_map[key_] = self.cache_head
return
if key_ in self.cache_map:
updateNode(); return
# Insert new node
if self.number_of_elements == 0:
self.cache_tail = Node(key_, value_)
self.cache_head = self.cache_tail
self.capacity -= 1
self.number_of_elements = 1
elif self.number_of_elements == 1:
self.cache_head = Node(key_, value_)
self.cache_head.next = self.cache_tail
self.cache_tail.prev = self.cache_head
self.capacity -= 1
self.number_of_elements += 1
else:
node = Node(key_, value_)
node.next = self.cache_head
self.cache_head.prev = node
self.cache_head = node
self.capacity -= 1
self.number_of_elements += 1
self.cache_map[key_] = self.cache_head
Binding the methods to the class¶
In [5]:
LRUCache.get = get
LRUCache.put = put
Testcase(s)¶
In [6]:
test = (["LRUCache", "put", "put", "get", "put", "get", "put", "get", "get", "get"],
[[2], [1, 1], [2, 2], [1], [3, 3], [2], [4, 4], [1], [3], [4]])
expected_result = [None, None, None, 1, None, -1, None, -1, 3, 4]
Driver code¶
In [7]:
result = []
cacheObj = None
for i in range(len(test[0])):
if test[0][i] == "LRUCache":
result.append(None)
cacheObj = LRUCache(test[1][i][0])
if test[0][i] == "put":
result.append(None)
key, value = test[1][i]
cacheObj.put(key, value)
if test[0][i] == "get":
value = cacheObj.get(test[1][i][0])
result.append(value)
if result == expected_result:
print(f"Result matches expected result\n{result}")
Result matches expected result [None, None, None, 1, None, -1, None, -1, 3, 4]